Listing :

Full listing of the phonebook with database from chapter 11.

import sys
import sqlite3
import os.path
from functools import total_ordering
from collections import UserList

DATABASE = """
create table types(id integer primary key autoincrement,
                   description text);
create table names(id integer primary key autoincrement,
                   name text);
create table telephones(id integer primary key autoincrement,
                        id_name integer,
                        number text,
                        id_type integer);
insert into types(description) values ("Cell");
insert into types(description) values ("Landline");
insert into types(description) values ("Fax");
insert into types(description) values ("Home");
insert into types(description) values ("Work");
"""


def none_or_empty(text):
    return text is None or not text.strip()


def validate_integer_range(question, start, end, default=None):
    while True:
        try:
            entry = input(question)
            if none_or_empty(entry) and default is not None:
                entry = default
            value = int(entry)
            if start <= value <= end:
                return value
        except ValueError:
            print(f"Invalid value, please enter between {start} and {end}")


def validate_integer_range_or_blank(question, start, end):
    while True:
        try:
            entry = input(question)
            if none_or_empty(entry):
                return None
            value = int(entry)
            if start <= value <= end:
                return value
        except ValueError:
            print(f"Invalid value, please enter between {start} and {end}")


class UniqueList(UserList):
    def __init__(self, elem_class, enumerable=None):
        super().__init__(enumerable)
        self.elem_class = elem_class

    def append(self, elem):
        self.verify_type(elem)
        if elem not in self.data:
            super().append(elem)

    def __setitem__(self, position, elem):
        self.verify_type(elem)
        if elem not in self.data:
            super().__setitem__(position, elem)

    def verify_type(self, elem):
        if not isinstance(elem, self.elem_class):
            raise TypeError("Invalid type")

    def search(self, elem):
        self.verify_type(elem)
        try:
            return self.index(elem)
        except ValueError:
            return -1


class DBUniqueList(UniqueList):
    def __init__(self, elem_class):
        super().__init__(elem_class)
        self.deleted = []

    def remove(self, elem):
        if elem.id is not None:
            self.deleted.append(elem.id)
        super().remove(elem)

    def clear(self):
        self.deleted = []


@total_ordering
class Name:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return self.name

    def __repr__(self):
        return "<Class {3} in 0x{0:x} Name: {1} Key: {2}>".format(
            id(self), self.__name, self.__key, type(self).__name__
        )

    def __eq__(self, other):
        return self.name == other.name

    def __lt__(self, other):
        return self.name < other.name

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self, value):
        if none_or_empty(value):
            raise ValueError("Name cannot be None or empty")
        self.__name = value
        self.__key = Name.CreateKey(value)

    @property
    def key(self):
        return self.__key

    @staticmethod
    def CreateKey(name):
        return name.strip().lower()


class DBName(Name):
    def __init__(self, name, id_=None):
        super().__init__(name)
        self.id = id_


@total_ordering
class PhoneType:
    def __init__(self, type):
        self.type = type

    def __str__(self):
        return f"({self.type})"

    def __eq__(self, other):
        if other is None:
            return False
        return self.type == other.type

    def __lt__(self, other):
        return self.type < other.type


class DBPhoneType(PhoneType):
    def __init__(self, id_, type):
        super().__init__(type)
        self.id = id_


class Telephone:
    def __init__(self, number, type=None):
        self.number = number
        self.type = type

    def __str__(self):
        type_ = self.type or ""
        return f"{self.number} {type_}"

    def __eq__(self, other):
        return self.number == other.number and (
            (self.type == other.type) or (self.type is None or other.type is None)
        )

    @property
    def number(self):
        return self.__number

    @number.setter
    def number(self, value):
        if none_or_empty(value):
            raise ValueError("Number cannot be None or empty")
        self.__number = value


class DBTelephone(Telephone):
    def __init__(self, number, type=None, id_=None, id_name=None):
        super().__init__(number, type)
        self.id = id_
        self.id_name = id_name


class DBTelephones(DBUniqueList):
    def __init__(self):
        super().__init__(DBTelephone)


class DBPhoneTypes(UniqueList):
    def __init__(self):
        super().__init__(DBPhoneType)


class DBPhoneData:
    def __init__(self, name):
        self.name = name
        self.phones = DBTelephones()

    @property
    def name(self):
        return self.__name

    @name.setter
    def name(self, value):
        if not isinstance(value, DBName):
            raise TypeError("name must be an instance of DBName")
        self.__name = value

    def search_phone(self, phone):
        position = self.phones.search(DBTelephone(phone))
        if position == -1:
            return None
        else:
            return self.phones[position]


class DBPhonebook:
    def __init__(self, database):
        self.phoneTypes = DBPhoneTypes()
        self.database = database
        is_new = not os.path.isfile(database)
        self.connection = sqlite3.connect(database)
        self.connection.row_factory = sqlite3.Row
        if is_new:
            self.create_database()
        self.load_types()

    def load_types(self):
        for type_ in self.connection.execute("select * from types"):
            id_ = type_["id"]
            description = type_["description"]
            self.phoneTypes.append(DBPhoneType(id_, description))

    def create_database(self):
        self.connection.executescript(DATABASE)

    def search_name(self, name):
        if not isinstance(name, DBName):
            raise TypeError("name must be of type DBName")
        found = self.connection.execute(
            """select count(*) from names where name = ?""",
            (name.name,),
        ).fetchone()
        if found[0] > 0:
            return self.load_by_name(name)
        else:
            return None

    def load_by_id(self, id):
        query = self.connection.execute("select * from names where id = ?", (id,))

        return self.load(query.fetchone())

    def load_by_name(self, name):
        query = self.connection.execute(
            "select * from names where name = ?", (name.name,)
        )
        return self.load(query.fetchone())

    def load(self, record):
        if record is None:
            return None
        new = DBPhoneData(DBName(record["name"], record["id"]))
        for phone in self.connection.execute(
            "select * from telephones where id_name = ?", (new.name.id,)
        ):
            phone_number = DBTelephone(
                phone["number"], None, phone["id"], phone["id_name"]
            )
            for type_ in self.phoneTypes:
                if type_.id == phone["id_type"]:
                    phone_number.type = type_
                    break
            new.phones.append(phone_number)
        return new

    def list(self):
        query = self.connection.execute("select * from names order by name")
        for record in query:
            yield self.load(record)

    def new_record(self, record):
        try:
            cur = self.connection.cursor()
            cur.execute("insert into names(name) values (?)", (str(record.name),))
            record.name.id = cur.lastrowid
            for phone in record.phones:
                cur.execute(
                    """insert into telephones(number,
                               id_type, id_name) values (?,?,?)""",
                    (phone.number, phone.type.id, record.name.id),
                )
                phone.id = cur.lastrowid
            self.connection.commit()
        except:
            self.connection.rollback()
            raise
        finally:
            cur.close()

    def update(self, record):
        try:
            cur = self.connection.cursor()
            cur.execute(
                "update names set name=? where id = ?",
                (str(record.name), record.name.id),
            )
            for phone in record.phones:
                if phone.id is None:
                    cur.execute(
                        """insert into telephones(number,
                                   id_type, id_name)
                                   values (?,?,?)""",
                        (phone.number, phone.type.id, record.name.id),
                    )
                    phone.id = cur.lastrowid
                else:
                    cur.execute(
                        """update telephones set number=?,
                                          id_type=?, id_name=?
                                          where id = ?""",
                        (
                            phone.number,
                            phone.type.id,
                            record.name.id,
                            phone.id,
                        ),
                    )
            for deleted in record.phones.deleted:
                cur.execute("delete from telephones where id = ?", (deleted,))
            self.connection.commit()
            record.phones.clear()
        except:
            self.connection.rollback()
            raise
        finally:
            cur.close()

    def delete(self, record):
        try:
            cur = self.connection.cursor()
            cur.execute("delete from telephones where id_name = ?", (record.name.id,))
            cur.execute("delete from names where id = ?", (record.name.id,))
            self.connection.commit()
        except:
            self.connection.rollback()
            raise
        finally:
            cur.close()


class Menu:
    def __init__(self):
        self.options = [["Exit", None]]

    def add_option(self, name, function):
        self.options.append([name, function])

    def show(self):
        print("====")
        print("Menu")
        print("====\n")
        for i, option in enumerate(self.options):
            print(f"[{i}] - {option[0]}")
        print()

    def execute(self):
        while True:
            self.show()
            option = validate_integer_range(
                "Choose an option: ", 0, len(self.options) - 1
            )
            if option == 0:
                break
            self.options[option][1]()


class PhonebookApp:

    @staticmethod
    def ask_name():
        return input("Name: ")

    @staticmethod
    def ask_phone():
        return input("Phone: ")

    @staticmethod
    def show_data(data):
        print(f"Name: {data.name}")
        for phone in data.phones:
            print(f"Phone: {phone}")
        print()

    @staticmethod
    def show_phone_data(data):
        print(f"Name: {data.name}")
        for i, phone in enumerate(data.phones):
            print(f"{i} - Phone: {phone}")
        print()

    def __init__(self, database):
        self.phonebook = DBPhonebook(database)
        self.menu = Menu()
        self.menu.add_option("New", self.new)
        self.menu.add_option("Edit", self.edit)
        self.menu.add_option("Delete", self.delete)
        self.menu.add_option("List", self.list)
        self.last_name = None

    def ask_phone_type(self, default=None):
        for i, type_ in enumerate(self.phonebook.phoneTypes):
            print(f" {i} - {type_} ", end=None)
        type_ = validate_integer_range(
            "Type: ", 0, len(self.phonebook.phoneTypes) - 1, default
        )
        return self.phonebook.phoneTypes[type_]

    def search(self, name):
        if isinstance(name, str):
            name = DBName(name)
        data = self.phonebook.search_name(name)
        return data

    def new(self):
        new_name = PhonebookApp.ask_name()
        if none_or_empty(new_name):
            return
        name = DBName(new_name)
        if self.search(name) is not None:
            print("Name already exists!")
            return
        record = DBPhoneData(name)
        self.menu_phones(record)
        self.phonebook.new_record(record)

    def delete(self):
        name = PhonebookApp.ask_name()
        if none_or_empty(name):
            return
        data = self.search(name)
        if data is not None:
            self.phonebook.delete(data)
        else:
            print("Name not found.")

    def edit(self):
        name = PhonebookApp.ask_name()
        if none_or_empty(name):
            return
        data = self.search(name)
        if data is not None:
            print("\nFound:\n")
            PhonebookApp.show_data(data)
            print("Press enter if you don't want to change the name")
            new_name = PhonebookApp.ask_name()
            if not none_or_empty(new_name):
                data.name.name = new_name
            self.menu_phones(data)
            self.phonebook.update(data)
        else:
            print("Name not found!")

    def menu_phones(self, data):
        while True:
            print("\nEditing phones\n")
            PhonebookApp.show_phone_data(data)
            if len(data.phones) > 0:
                print("\n[E] - edit\n[D] - delete\n", end="")
            print("[N] - new\n[X] - exit\n")
            operation = input("Choose an operation: ")
            operation = operation.lower()
            if operation not in ["e", "d", "n", "x"]:
                print("Invalid operation. Enter E, D, N, or X")
                continue
            if operation == "e" and len(data.phones) > 0:
                self.edit_phones(data)
            elif operation == "d" and len(data.phones) > 0:
                self.delete_phone(data)
            elif operation == "n":
                self.new_phone(data)
            elif operation == "x":
                break

    def new_phone(self, data):
        phone = PhonebookApp.ask_phone()
        if none_or_empty(phone):
            return
        if data.search_phone(phone) is not None:
            print("Phone already exists")
        type_ = self.ask_phone_type()
        data.phones.append(DBTelephone(phone, type_))

    def delete_phone(self, data):
        to_be_deleted = validate_integer_range_or_blank(
            "Enter the position of the number to delete, press enter to exit: ",
            0,
            len(data.phones) - 1,
        )
        if to_be_deleted is None:
            return
        data.phones.remove(data.phones[to_be_deleted])

    def edit_phones(self, data):
        to_be_edited = validate_integer_range_or_blank(
            "Enter the position of the number to edit, press enter to exit: ",
            0,
            len(data.phones) - 1,
        )
        if to_be_edited is None:
            return
        phone = data.phones[to_be_edited]
        print(f"Phone: {phone}")
        print("Press enter if you don't want to edit the number")
        new_phone = PhonebookApp.ask_phone()
        if not none_or_empty(new_phone):
            phone.number = new_phone
        print("Press enter if you don't want to edit the type")
        phone.type = self.ask_phone_type(self.phonebook.phoneTypes.search(phone.type))

    def list(self):
        print("\nPhonebook")
        print("-" * 60)
        for e in self.phonebook.list():
            PhonebookApp.show_data(e)
        print("-" * 60)

    def execute(self):
        self.menu.execute()


if __name__ == "__main__":
    if len(sys.argv) > 1:
        app = PhonebookApp(sys.argv[1])
        app.execute()
    else:
        print("Error: database name not provided")
        print("Usage phonebook.py database_name")
Click here to download the file